Skip to content

fix: validate the dispute initiator before refunding the escrow (#805) - #825

Open
grunch wants to merge 3 commits into
mainfrom
fix/admin-cancel-validate-before-refund
Open

fix: validate the dispute initiator before refunding the escrow (#805)#825
grunch wants to merge 3 commits into
mainfrom
fix/admin-cancel-validate-before-refund

Conversation

@grunch

@grunch grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member

Closes #805.

The bug

admin_cancel_action refunded the seller's escrow before it finished validating the request:

if order.hash.is_some() {
    ln_client.cancel_hold_invoice(hash).await?;   // irreversible
}
...
let dispute_initiator = match (order.seller_dispute, order.buyer_dispute) {
    (true, false) => "seller",
    (false, true) => "buyer",
    (_, _) => return Err(MostroInternalErr(ServiceError::DisputeEventError)),  // rejects — too late
};

When neither initiator flag is set (or both are), the handler returns DisputeEventError — but cancel_hold_invoice has already returned the funds to the seller. The order stays in Dispute, the transition to CanceledByAdmin never runs, and the dispute row is never moved to SellerRefunded. The Lightning side and the DB disagree permanently, and the refund cannot be undone.

The fix

Move the initiator resolution above the refund, so every check that can reject the call runs before the escrow is touched. This is the same ordering discipline already applied to the status guards and the bond-resolution validation just above it (see the existing Phase 2 comment). The valid path — a legitimately flagged initiator — is unchanged.

Test

New regression test dispute_without_initiator_flag_errors_before_refunding: an order with a hold-invoice hash and no initiator flag must fail with DisputeEventError and be left in Dispute.

The test is a genuine before/after discriminator thanks to the existing dead_lnd() harness (a real LndConnector against a dead endpoint, so any RPC fails fast):

  • Before the fix it failed with Err(MostroInternalErr(LnNodeError("code=Unknown message=client error (Connect)"))) — proof the refund RPC had already been dispatched.
  • After the fix it returns DisputeEventError without ever reaching LND.

dispute_with_hash_reaches_ln_cancel_seam still passes, confirming the legitimate refund path is untouched.

Verification

  • cargo test1016 passed, 0 failed
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --all — clean

Note

admin_settle.rs has related ordering/CAS concerns tracked separately in #809 and #810; this PR deliberately stays scoped to #805.

Manual testing

The unit tests pin the ordering at the handler seam; this is how to reproduce
the bug and verify the fix end to end against a real LND. The single
observable that matters throughout: on a request that is going to be
rejected, the hold invoice must still be ACCEPTED afterwards.

0. Environment

  • LND on regtest (Polar) + a local relay: cd docker && make docker-up
    (see docker/README.md), or cargo run against Polar directly.
  • A solver key with read-write permission (admin-add-solver), plus
    mostro-cli for the maker/taker/solver messages.
  • Optional but recommended for test E: [rpc] enabled = true in
    settings.toml, so CancelOrder can be driven over gRPC and the error is
    returned to the caller instead of only logged.
  • Build the branch: git checkout fix/admin-cancel-validate-before-refund && cargo run

Inspection helpers used below (<ID> = order id, <HASH> = orders.hash):

sqlite3 mostro.db "select status, hash, seller_dispute, buyer_dispute, \
  seller_pubkey is null as no_seller, buyer_pubkey is null as no_buyer \
  from orders where id='<ID>';"
sqlite3 mostro.db "select id, status from disputes where order_id='<ID>';"
sqlite3 mostro.db "select role, state from bonds where order_id='<ID>';"   # only if the bond feature is on
lncli --network regtest lookupinvoice <HASH>       # .state: ACCEPTED | CANCELED | SETTLED

1. Common setup — a disputed order with a live escrow

  1. Maker publishes an order, taker takes it.
  2. Seller pays the hold invoice → lncli lookupinvoice <HASH> shows
    ACCEPTED; buyer sends the payout invoice; buyer sends fiat-sent.
  3. Either party sends dispute.
  4. Solver sends admin-take-dispute.

Checkpoint before every test below: orders.status = dispute, exactly one
of seller_dispute / buyer_dispute is 1, orders.hash is set,
disputes.status = in-progress, invoice state ACCEPTED, any bond rows
locked.

2. Test A — the legitimate path is unchanged (regression guard)

Send admin-cancel for the order as the assigned solver.

Must all hold to approve:

  • lookupinvoice <HASH>CANCELED (escrow returned to the seller).
  • orders.statuscanceled-by-admin.
  • disputes.statusseller-refunded.
  • A kind-38386 event is published with s = seller-refunded and
    initiator = <the side that actually opened the dispute> — this tag must
    match the flag set in step 1.3, not an arbitrary side.
  • Solver, seller and buyer all receive the admin-canceled DM.
  • Bond rows (if enabled) leave lockedreleased (or slashed when a
    BondResolution was attached).

3. Test B — ambiguous initiator must not touch the escrow (the #805 bug)

The flags are only corruptible out of band, so force the state directly on a
freshly disputed order (repeat §1):

sqlite3 mostro.db "update orders set seller_dispute=0, buyer_dispute=0 where id='<ID>';"

Send admin-cancel. Must all hold:

  • lookupinvoice <HASH>still ACCEPTED. (On main this returns
    CANCELED: the seller was refunded on a request that was then rejected.)
  • orders.status → still dispute; disputes.status still
    in-progress; bond rows still locked.
  • The daemon logs
    admin_cancel: ambiguous dispute initiator flags; refusing before the escrow is touched
    with order_id, seller_dispute=false, buyer_dispute=false.
  • Over gRPC the call fails with Admin cancel failed: … DisputeEventError;
    over Nostr the daemon logs the warning and no admin-canceled DM is
    emitted to anyone.
  • Retry proof: restore the correct flag
    (update orders set seller_dispute=1 …) and re-send admin-cancel → it
    now completes exactly as in Test A. Nothing was stranded by the rejection.

4. Test C — both flags set is equally ambiguous

Same as Test B, but set seller_dispute=1, buyer_dispute=1. Same expected
result: rejected, invoice ACCEPTED, order dispute, same log line with
seller_dispute=true, buyer_dispute=true. (Pre-fix this arm also refunded
first.)

5. Test D — missing counterparty pubkey is rejected before the refund

On a fresh disputed order:

sqlite3 mostro.db "update orders set buyer_pubkey=NULL where id='<ID>';"

Send admin-cancel. Must all hold:

  • Rejected with InvalidPubkey; lookupinvoice <HASH>still
    ACCEPTED
    ; orders.status still dispute; disputes.status
    unchanged; bond rows still locked.
  • Restore the pubkey and re-send → completes as in Test A.

(On main this rejection happened after the refund, after the dispute row
was moved to seller-refunded and after the order became
canceled-by-admin — past the point where a retry is possible, since the
Dispute status guard rejects the second attempt, leaving the bonds
Locked with no reconciler.)

6. Test E — a failing DM must not abort the bond resolution

Needs [rpc] enabled = true so the command does not travel over the relay
that is about to be stopped.

  1. Set up a disputed order per §1, with the bond feature enabled so there are
    locked bond rows.
  2. Stop the relay (docker compose stop nostr-relay).
  3. Call CancelOrder over gRPC.

Must all hold:

  • The gRPC call returns success.
  • The log shows admin_cancel: failed to notify the admin/seller/buyer of the cancellation: … (one line per undelivered recipient) — and execution
    continues past them.
  • orders.status = canceled-by-admin, invoice CANCELED, and critically the
    bond rows are no longer locked.
    (On main the first send_dm error returned early, so the bonds stayed
    Locked with no retry path.)

7. Test F — admin-settle got the same treatment

Fresh disputed order per §1 (buyer payout invoice present), then corrupt the
flags as in Test B and send admin-settle.

Must all hold:

  • Rejected with DisputeEventError; log line
    admin_settle: ambiguous dispute initiator flags; refusing before the escrow is settled.
  • lookupinvoice <HASH>still ACCEPTED, i.e. not SETTLED — the
    seller's escrow was not captured. orders.status still dispute.
  • Restore the flag and re-send admin-settle → the invoice goes to
    SETTLED, the order leaves dispute (settled-hold-invoice, then the
    payout to the buyer), disputes.status = settled, and the admin-settled
    DMs go out.

8. Approval criteria

Approve only if every criterion below is observed:

# Criterion
1 Test A: the legitimate cancel behaves exactly as on main, with the correct initiator tag.
2 Tests B, C, D, F: on every rejected request, lookupinvoice still reports ACCEPTEDno LND state change at all.
3 Tests B, C, D, F: on every rejected request the order stays in dispute and the dispute row keeps its previous status.
4 Tests B, C, D, F: after repairing the row, the retry succeeds — no request is left unrecoverable.
5 Test E: a dead relay yields a successful cancel with per-recipient error logs, and no bond row left locked.
6 Every rejection is diagnosable from the logs alone (order id + both flag values), with no admin-canceled DM sent to any party.

Reject if any rejected request shows a CANCELED/SETTLED invoice, an order
that left dispute, a bond stuck in locked after a successful cancel, or a
retry that is refused after the underlying row was repaired.

Optional before/after: run tests B and D against main first — the
invoice ends CANCELED while the order stays in dispute, which is the
exact split-brain #805 describes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved dispute cancellation validation: now rejects missing/ambiguous dispute initiator and invalid counterparty pubkeys before any refund/cancellation side effects, keeping disputed orders in Dispute for safe retries.
    • Improved dispute settlement flow: determines dispute initiator before irreversible settlement and errors out early on corrupted/ambiguous initiator flags.
    • Made admin/seller/buyer notification fan-out best-effort so messaging failures no longer abort completion.
  • Tests
    • Added/extended regression tests for cancellation (issue #805) to verify early failure ordering and unchanged Dispute status; added a settlement pre-check test for missing initiator flags.

`admin_cancel_action` called `cancel_hold_invoice` — an irreversible refund of
the seller's escrow — before resolving the dispute initiator, and that
resolution can reject the request with `DisputeEventError` when neither
`seller_dispute` nor `buyer_dispute` is set (or both are).

On that path the seller was already refunded while the order stayed in
`Dispute` and the status transition to `CanceledByAdmin` never ran, leaving
the Lightning side and the DB permanently disagreeing with no way back.

Move the initiator resolution above the refund so every check that can reject
the call runs before the escrow is touched. The valid path is unchanged.

Regression test: an order with a hold-invoice hash and no initiator flag now
fails with `DisputeEventError` and is left in `Dispute`. Before the fix it
returned `LnNodeError` — proof the refund RPC had already been dispatched.

Closes #805
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

admin_cancel_action and admin_settle_action now validate dispute initiators before irreversible Lightning actions. Cancellation also validates counterparty keys and treats post-update DM failures as non-fatal, with regression tests covering these paths.

Changes

Admin dispute action safety

Layer / File(s) Summary
Cancellation pre-refund validation
src/app/admin_cancel.rs
Resolves the initiator and validates counterparty pubkeys before refund, with regression coverage for unset or ambiguous flags and invalid keys.
Post-cancellation notifications
src/app/admin_cancel.rs
DM fan-out failures are logged without aborting the completed cancellation or bond resolution.
Settlement preflight validation
src/app/admin_settle.rs
Resolves the initiator before settlement and rejects invalid flags while leaving the order in Status::Dispute.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Admin
  participant Handler
  participant Order
  participant Lightning
  participant Notifications
  Admin->>Handler: submit dispute action
  Handler->>Order: validate initiator and required keys
  alt invalid state
    Handler-->>Admin: DisputeEventError
  else valid state
    Handler->>Lightning: cancel or settle hold invoice
    Handler->>Order: update order state
    Handler->>Notifications: send admin, seller, and buyer DMs
    Notifications-->>Handler: log delivery failures
  end
Loading

Suggested reviewers: arkanoider

Poem

A rabbit checks the flags with care,
Before Lightning moves funds there.
Keys are parsed, bad paths decline,
Failed notes don’t stop the line.
Hop safely onward, bond by bond!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The admin_settle pre-validation change is unrelated to #805, which only targets admin_cancel's refund ordering. Split the admin_settle changes into a separate PR or remove them if they were not intended for this fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR validates the initiator before cancel_hold_invoice and blocks invalid disputes from releasing funds, matching #805.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: validating the dispute initiator before the irreversible escrow refund.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/admin-cancel-validate-before-refund

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@grunch

grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

ermeme[bot]
ermeme Bot previously approved these changes Jul 20, 2026

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strict review completed on the current head.

The change fixes the funds-safety ordering bug without altering the valid cancellation path: malformed/ambiguous initiator flags are now rejected before the irreversible hold-invoice cancellation, while the existing valid-hash test still proves that a legitimate request reaches the LND cancellation seam. The new regression test is a real before/after discriminator because the old ordering returns LnNodeError against the dead-LND harness before it can produce DisputeEventError.

I also checked authorization, dispute-status and bond-resolution ordering, the seller/buyer initiator mapping used in the kind-38386 dispute event, retry behavior in the malformed-state path, and the tracked CAS/atomicity concerns outside this PR's scope. No blocking regression found.

CI is green for build, tests, fmt, clippy, and the Rust 1.94.0 MSRV build.

Comment thread src/app/admin_cancel.rs Outdated
@grunch

grunch commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Reviewer feedback on #825: the comment claimed every check that can
reject the call precedes the refund, which overstates the invariant.
The handler still has fallible steps after the refund (dispute/order
events, DB updates, DM delivery); atomicity there is tracked in #810.
Follow-up to the strict review of #825. The PR moved the dispute-initiator
resolution above cancel_hold_invoice, but three gaps of the same class (#805)
remained:

- admin_cancel resolved the counterparty pubkeys only after the refund, the
  dispute row and the order status had been written. A missing or unparseable
  pubkey rejected the call once the money had already moved, past any solver
  retry (the Dispute guard rejects a second cancel) and before the bond
  resolution, stranding every Locked bond — only range maker bonds have a
  reconciler. The resolution now runs next to the initiator check.
- The DM fan-out returned on the first failure, skipping the same bond
  resolution on a mere relay hiccup. Delivery is now best effort with an
  error log per recipient, mirroring notify_bond_slashed.
- admin_settle still resolved the initiator after settle_seller_hold_invoice,
  which is equally irreversible. Hoisted above the settle.

Both ambiguous-flag arms now log order id and both flags: DisputeEventError
alone gave the operator nothing to diagnose with.

Tests: an unparseable-pubkey rejection and an ambiguous-flag settle are each
pinned before their escrow move (both fail against the previous ordering),
plus the both-flags-set arm and an assertion that a failed DM no longer
aborts admin_cancel before the bond resolution. Stale doc comments on the
existing tests corrected.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/admin_cancel.rs (1)

794-807: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Make the DM-failure regression actually exercise the failure path.

With no global Nostr client, send_dm returns Ok(()); this test never enters the new Err branch. It also creates no locked bond, so it does not prove bond resolution ran. Inject/configure a notifier that deterministically fails and assert the seeded bond reaches its expected resolved state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin_cancel.rs` around lines 794 - 807, Update the regression test
around admin_cancel_action to configure a deterministic failing notifier/client
so send_dm enters the Err path, and seed a locked bond before invoking the
handler. Afterward, retain the successful handler assertion and verify the
seeded bond reaches its expected resolved state, proving bond resolution
continues despite DM failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/app/admin_cancel.rs`:
- Around line 794-807: Update the regression test around admin_cancel_action to
configure a deterministic failing notifier/client so send_dm enters the Err
path, and seed a locked bond before invoking the handler. Afterward, retain the
successful handler assertion and verify the seeded bond reaches its expected
resolved state, proving bond resolution continues despite DM failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 03d53ed9-ed53-4e1d-bfe2-57f40e55e731

📥 Commits

Reviewing files that changed from the base of the PR and between a88e794 and e6f86a5.

📒 Files selected for processing (2)
  • src/app/admin_cancel.rs
  • src/app/admin_settle.rs

@AndreaDiazCorreia AndreaDiazCorreia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tACK

@grunch
grunch requested review from BraCR10, Catrya and arkanoider July 29, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[HIGH] admin_cancel refunds the hold invoice before validating the dispute initiator

2 participants